chore: version dependencies ui#1086
Conversation
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR implements the UI surface for deployment dependencies. It adds backend TRPC endpoints to fetch dependency metadata and current versions, creates new frontend components to display dependencies with version selector evaluation, and integrates these components into both the deployment version view and release target page. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant DependencyDetail as DependencyDetail Component
participant TRPC as TRPC deploymentVersions.dependencies
participant DB as Database
participant CEL as cel-js Evaluator
participant UI as Dependency Dialog UI
User->>DependencyDetail: Render with versionId
DependencyDetail->>TRPC: useQuery({ versionId })
TRPC->>DB: Get deploymentVersion metadata
DB-->>TRPC: version data
TRPC->>DB: Query deploymentVersionDependency relationships
DB-->>TRPC: dependency edges
TRPC->>DB: Fetch target environment/resource pairs
DB-->>TRPC: targets
TRPC->>DB: Query release jobs for current versions per target
DB-->>TRPC: current versions
TRPC-->>DependencyDetail: { version, dependencies[] }
DependencyDetail->>CEL: Evaluate each dependency.versionSelector against currentVersion
CEL-->>DependencyDetail: satisfied/unsatisfied per target
DependencyDetail->>DependencyDetail: Aggregate satisfaction counts
DependencyDetail->>UI: Render with summary and expandable dependency list
UI-->>User: Display satisfied/blocked status and details
sequenceDiagram
participant User
participant Dependencies as Dependencies Component
participant TRPC as TRPC releaseTargets.dependencies
participant DB as Database
participant CEL as cel-js Evaluator
participant Card as Dependency Card UI
User->>Dependencies: Render with deploymentId, environmentId, resourceId
Dependencies->>TRPC: useQuery({ deploymentId, environmentId, resourceId })
TRPC->>DB: Find desired or latest deploymentVersion
DB-->>TRPC: version
TRPC->>DB: Query deploymentVersionDependency for version
DB-->>TRPC: dependencies
TRPC->>DB: Find most recent successful release for each dependency
DB-->>TRPC: currentVersion per dependency
TRPC-->>Dependencies: { version, dependencies[] }
Dependencies->>CEL: Evaluate versionSelector against currentVersion
CEL-->>Dependencies: satisfied/unsatisfied per dependency
Dependencies->>Dependencies: Compute satisfiedCount/total
Dependencies->>Card: Render header with satisfaction ratio
Card->>Card: Render per-dependency rows with selector and version
Card-->>User: Display card with satisfied/unsatisfied indicators
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested Reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (1 warning, 2 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 0/1 reviews remaining, refill in 45 minutes and 58 seconds.Comment |
There was a problem hiding this comment.
Pull request overview
Surfaces deployment-version dependency status in the web UI (issue #1080) by adding TRPC endpoints that return dependency edges plus “current” upstream versions, and by rendering those dependencies on release-target and policy-evaluation views.
Changes:
- Add
dependenciesqueries to TRPCreleaseTargetsanddeploymentVersionsrouters to fetch declared dependency edges and current upstream versions. - Show a Dependencies card on the release-target evaluations page.
- Add a dependency detail dialog to the environment version decision UI.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/trpc/src/routes/release-targets.ts | New releaseTargets.dependencies query returning declared dependencies and current upstream version per dependency. |
| packages/trpc/src/routes/deployment-versions.ts | New deploymentVersions.dependencies query returning dependencies and per-target current upstream versions. |
| apps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsx | Wires the new Dependencies UI into the release-target page. |
| apps/web/app/routes/ws/deployments/_components/release-targets/Dependencies.tsx | New card UI that evaluates dependency selectors client-side and displays satisfaction status. |
| apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx | New dialog UI to summarize blocked targets and drill into dependency satisfaction per target. |
| apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsx | Adds the dependency detail component into the version decision stack. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (resourceIds.length > 0) { | ||
| await Promise.all( | ||
| edges.map(async (edge) => { | ||
| const rows = await ctx.db | ||
| .select({ | ||
| resourceId: schema.release.resourceId, | ||
| versionId: schema.release.versionId, | ||
| tag: schema.deploymentVersion.tag, | ||
| name: schema.deploymentVersion.name, | ||
| status: schema.deploymentVersion.status, | ||
| }) | ||
| .from(schema.release) | ||
| .innerJoin( | ||
| schema.releaseJob, | ||
| eq(schema.releaseJob.releaseId, schema.release.id), | ||
| ) | ||
| .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) | ||
| .innerJoin( | ||
| schema.deploymentVersion, | ||
| eq(schema.deploymentVersion.id, schema.release.versionId), | ||
| ) | ||
| .where( | ||
| and( | ||
| eq(schema.release.deploymentId, edge.dependencyDeploymentId), | ||
| inArray(schema.release.resourceId, resourceIds), | ||
| eq(schema.job.status, "successful"), | ||
| isNotNull(schema.job.completedAt), | ||
| ), | ||
| ) | ||
| .orderBy(desc(schema.job.completedAt)); | ||
|
|
||
| const byResource = new Map< | ||
| string, | ||
| { id: string; tag: string; name: string; status: string } | ||
| >(); | ||
| for (const row of rows) { | ||
| if (byResource.has(row.resourceId)) continue; | ||
| byResource.set(row.resourceId, { | ||
| id: row.versionId, | ||
| tag: row.tag, | ||
| name: row.name, | ||
| status: row.status, | ||
| }); | ||
| } | ||
| currentByDepResource.set(edge.dependencyDeploymentId, byResource); | ||
| }), | ||
| ); |
There was a problem hiding this comment.
For each dependency edge, this fetches all successful releases across all matching resources and then de-dupes in JS (orderBy(desc(job.completedAt)) + if (byResource.has(...)) continue). This can become expensive for long-lived deployments. Prefer doing the “latest per (resource, environment)” selection in SQL (e.g., DISTINCT ON / window function) and/or fetching for all dependencyDeploymentIds in one query instead of one query per edge.
| if (resourceIds.length > 0) { | |
| await Promise.all( | |
| edges.map(async (edge) => { | |
| const rows = await ctx.db | |
| .select({ | |
| resourceId: schema.release.resourceId, | |
| versionId: schema.release.versionId, | |
| tag: schema.deploymentVersion.tag, | |
| name: schema.deploymentVersion.name, | |
| status: schema.deploymentVersion.status, | |
| }) | |
| .from(schema.release) | |
| .innerJoin( | |
| schema.releaseJob, | |
| eq(schema.releaseJob.releaseId, schema.release.id), | |
| ) | |
| .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) | |
| .innerJoin( | |
| schema.deploymentVersion, | |
| eq(schema.deploymentVersion.id, schema.release.versionId), | |
| ) | |
| .where( | |
| and( | |
| eq(schema.release.deploymentId, edge.dependencyDeploymentId), | |
| inArray(schema.release.resourceId, resourceIds), | |
| eq(schema.job.status, "successful"), | |
| isNotNull(schema.job.completedAt), | |
| ), | |
| ) | |
| .orderBy(desc(schema.job.completedAt)); | |
| const byResource = new Map< | |
| string, | |
| { id: string; tag: string; name: string; status: string } | |
| >(); | |
| for (const row of rows) { | |
| if (byResource.has(row.resourceId)) continue; | |
| byResource.set(row.resourceId, { | |
| id: row.versionId, | |
| tag: row.tag, | |
| name: row.name, | |
| status: row.status, | |
| }); | |
| } | |
| currentByDepResource.set(edge.dependencyDeploymentId, byResource); | |
| }), | |
| ); | |
| const dependencyDeploymentIds = edges.map( | |
| (edge) => edge.dependencyDeploymentId, | |
| ); | |
| if (resourceIds.length > 0 && dependencyDeploymentIds.length > 0) { | |
| const rows = await ctx.db | |
| .select({ | |
| deploymentId: schema.release.deploymentId, | |
| resourceId: schema.release.resourceId, | |
| versionId: schema.release.versionId, | |
| tag: schema.deploymentVersion.tag, | |
| name: schema.deploymentVersion.name, | |
| status: schema.deploymentVersion.status, | |
| }) | |
| .from(schema.release) | |
| .innerJoin( | |
| schema.releaseJob, | |
| eq(schema.releaseJob.releaseId, schema.release.id), | |
| ) | |
| .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) | |
| .innerJoin( | |
| schema.deploymentVersion, | |
| eq(schema.deploymentVersion.id, schema.release.versionId), | |
| ) | |
| .where( | |
| and( | |
| inArray(schema.release.deploymentId, dependencyDeploymentIds), | |
| inArray(schema.release.resourceId, resourceIds), | |
| eq(schema.job.status, "successful"), | |
| isNotNull(schema.job.completedAt), | |
| ), | |
| ) | |
| .orderBy( | |
| asc(schema.release.deploymentId), | |
| asc(schema.release.resourceId), | |
| desc(schema.job.completedAt), | |
| ); | |
| for (const row of rows) { | |
| let byResource = currentByDepResource.get(row.deploymentId); | |
| if (byResource == null) { | |
| byResource = new Map< | |
| string, | |
| { id: string; tag: string; name: string; status: string } | |
| >(); | |
| currentByDepResource.set(row.deploymentId, byResource); | |
| } | |
| if (byResource.has(row.resourceId)) continue; | |
| byResource.set(row.resourceId, { | |
| id: row.versionId, | |
| tag: row.tag, | |
| name: row.name, | |
| status: row.status, | |
| }); | |
| } |
| .where( | ||
| and( | ||
| eq(schema.release.deploymentId, edge.dependencyDeploymentId), | ||
| eq(schema.release.resourceId, input.resourceId), |
There was a problem hiding this comment.
The upstream release lookup does not filter by input.environmentId, so it can pick a successful dependency release from a different environment for the same resource. Since release is keyed by (resourceId, environmentId, deploymentId), this can cause the UI to mark a dependency satisfied/unsatisfied incorrectly. Include an eq(schema.release.environmentId, input.environmentId) condition (or otherwise ensure you’re selecting the release for the same release-target environment).
| eq(schema.release.resourceId, input.resourceId), | |
| eq(schema.release.resourceId, input.resourceId), | |
| eq(schema.release.environmentId, input.environmentId), |
| const dependencies = await Promise.all( | ||
| edges.map(async (edge) => { | ||
| const upstream = await ctx.db | ||
| .select({ | ||
| versionId: schema.release.versionId, | ||
| versionTag: schema.deploymentVersion.tag, | ||
| versionName: schema.deploymentVersion.name, | ||
| versionStatus: schema.deploymentVersion.status, | ||
| environmentId: schema.release.environmentId, | ||
| completedAt: schema.job.completedAt, | ||
| }) | ||
| .from(schema.release) | ||
| .innerJoin( | ||
| schema.releaseJob, | ||
| eq(schema.releaseJob.releaseId, schema.release.id), | ||
| ) | ||
| .innerJoin( | ||
| schema.job, | ||
| eq(schema.job.id, schema.releaseJob.jobId), | ||
| ) | ||
| .innerJoin( | ||
| schema.deploymentVersion, | ||
| eq(schema.deploymentVersion.id, schema.release.versionId), | ||
| ) | ||
| .where( | ||
| and( | ||
| eq(schema.release.deploymentId, edge.dependencyDeploymentId), | ||
| eq(schema.release.resourceId, input.resourceId), | ||
| eq(schema.job.status, "successful"), | ||
| isNotNull(schema.job.completedAt), | ||
| ), | ||
| ) | ||
| .orderBy(desc(schema.job.completedAt)) | ||
| .limit(1); | ||
|
|
There was a problem hiding this comment.
This performs one SQL query per dependency edge (Promise.all(edges.map(...))). With many dependencies this becomes an N+1 pattern and adds latency. Consider fetching the latest successful release per dependency deployment in a single query (e.g., inArray(release.deploymentId, ...) + grouping / window function / DISTINCT ON) and then mapping results in memory.
| const rows = await ctx.db | ||
| .select({ | ||
| resourceId: schema.release.resourceId, | ||
| versionId: schema.release.versionId, | ||
| tag: schema.deploymentVersion.tag, | ||
| name: schema.deploymentVersion.name, | ||
| status: schema.deploymentVersion.status, | ||
| }) | ||
| .from(schema.release) | ||
| .innerJoin( | ||
| schema.releaseJob, | ||
| eq(schema.releaseJob.releaseId, schema.release.id), | ||
| ) | ||
| .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) | ||
| .innerJoin( | ||
| schema.deploymentVersion, | ||
| eq(schema.deploymentVersion.id, schema.release.versionId), | ||
| ) | ||
| .where( | ||
| and( | ||
| eq(schema.release.deploymentId, edge.dependencyDeploymentId), | ||
| inArray(schema.release.resourceId, resourceIds), | ||
| eq(schema.job.status, "successful"), | ||
| isNotNull(schema.job.completedAt), | ||
| ), | ||
| ) | ||
| .orderBy(desc(schema.job.completedAt)); |
There was a problem hiding this comment.
The “current version” lookup ignores release.environmentId entirely and de-duplicates only by resourceId. If the same resource exists in multiple environments, this will attach the latest successful release from an arbitrary environment to all targets for that resource, producing incorrect dependency satisfaction per-environment. Include release.environmentId in the query and key the map by (environmentId, resourceId) (or query per target environment).
| targets: targets.map((t) => ({ | ||
| resourceId: t.resourceId, | ||
| resourceName: t.resourceName, | ||
| environmentId: t.environmentId, | ||
| environmentName: t.environmentName, | ||
| currentVersion: byResource?.get(t.resourceId) ?? null, | ||
| })), |
There was a problem hiding this comment.
currentVersion is looked up via byResource?.get(t.resourceId) even though targets are environment-specific. If the same resourceId appears under multiple environments, this will reuse the same current version across environments. Once the upstream query is fixed, use an environment-aware key here as well (e.g., lookup by ${t.environmentId}:${t.resourceId}).
| <button | ||
| type="button" | ||
| onClick={onToggle} | ||
| className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent" | ||
| > | ||
| <Caret className="size-3.5 text-muted-foreground" /> | ||
| <StatusIcon satisfied={dependency.allSatisfied} /> | ||
| <Link | ||
| to={`/${workspaceSlug}/deployments/${dependency.dependencyDeploymentId}`} | ||
| target="_blank" | ||
| className="text-sm font-medium hover:underline" | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| {dependency.dependencyDeploymentName ?? | ||
| dependency.dependencyDeploymentId} | ||
| </Link> |
There was a problem hiding this comment.
DependencyGroupHeader renders a <Link> (anchor) inside a <button>, which is invalid HTML and can break keyboard/screen-reader interaction. Refactor so there is only one interactive element (e.g., make the header a non-button container with a separate toggle button, or make the whole row a button and move the link outside).
| {versionLabel} declares {evaluated.length} dependenc | ||
| {evaluated.length === 1 ? "y" : "ies"}.{" "} |
There was a problem hiding this comment.
Typo in user-facing text: dependenc is missing characters and will render incorrectly. Update the string to dependencies (and keep the pluralization logic intact).
| {versionLabel} declares {evaluated.length} dependenc | |
| {evaluated.length === 1 ? "y" : "ies"}.{" "} | |
| {versionLabel} declares {evaluated.length}{" "} | |
| {evaluated.length === 1 ? "dependency" : "dependencies"}.{" "} |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx`:
- Around line 169-184: The header currently nests the Link inside the toggle
<button>, which is invalid and causes focus/keyboard issues; refactor the JSX in
DependencyDetail (the clickable header using onToggle and the <Link> that
navigates to /{workspaceSlug}/deployments/{dependency.dependencyDeploymentId})
so the toggle button and the deployment Link are sibling elements (e.g., wrap
them in a flex container div), keep the toggle button using onToggle and the
StatusIcon/Caret, move the Link out as a separate element that keeps its text
styles and onClick={(e)=>e.stopPropagation()} to avoid triggering the toggle,
and preserve accessibility (type="button" on the toggle) and original
styling/gap behavior.
- Around line 323-326: DependencyDetail currently polls via
trpc.deploymentVersions.dependencies.useQuery({ versionId }, { refetchInterval:
15_000 }) even when its dialog is closed; add local dialog state (const [open,
setOpen] = useState(false)), pass open={open} onOpenChange={setOpen} to the
Dialog component, and gate the query by either using the enabled: open option or
set refetchInterval: open ? 15_000 : false so the background polling only runs
while the dialog is open.
In `@packages/trpc/src/routes/deployment-versions.ts`:
- Around line 267-315: The current cache keys only by resourceId causing
cross-environment bleed; modify the query to select schema.release.environmentId
and restrict results with eq(schema.release.environmentId,
edge.dependencyEnvironmentId), and change the cache shape so
currentByDepResource maps dependencyDeploymentId -> Map<environmentId,
Map<resourceId, {id, tag, name, status}>> (i.e., replace the inner Map<string,
{..}> with a Map<string, Map<string, {..}>>), populate/lookup using both
environmentId and resourceId when building byResource and when setting
currentByDepResource for edge.dependencyDeploymentId so dependency results are
scoped per environment.
In `@packages/trpc/src/routes/release-targets.ts`:
- Around line 388-419: The upstream query is selecting the latest successful
release for the resource across all environments; add an environment filter to
the where clause so only releases from the matching target environment are
considered. Update the and(...) in the query used to build `upstream` (the chain
starting with ctx.db.select(...) and .where(and(...))) to include
eq(schema.release.environmentId, edge.dependencyEnvironmentId) (or the
appropriate target environment identifier if the target env is on `input`), so
the query only returns upstream releases from the same environment as the
dependency.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ad2db75a-82e1-442a-a27b-102196f4a6ac
📒 Files selected for processing (6)
apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsxapps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsxapps/web/app/routes/ws/deployments/_components/release-targets/Dependencies.tsxapps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsxpackages/trpc/src/routes/deployment-versions.tspackages/trpc/src/routes/release-targets.ts
| const { data, isLoading } = trpc.deploymentVersions.dependencies.useQuery( | ||
| { versionId }, | ||
| { refetchInterval: 15_000 }, | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect where DependencyDetail is mounted and whether it can fan out across lists of versions.
rg -n -C3 '\bDependencyDetail\b|deploymentVersions\.dependencies\.useQuery' apps/web/app/routes/ws/deployments --iglob '*.tsx'
rg -n -C3 '\bDeploymentVersion\b' apps/web/app/routes/ws/deployments --iglob '*.tsx'Repository: ctrlplanedev/ctrlplane
Length of output: 9629
🏁 Script executed:
# Read DependencyDetail.tsx to understand the dialog structure and the query at lines 345-357
cat -n apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx | sed -n '300,370p'Repository: ctrlplanedev/ctrlplane
Length of output: 2077
🏁 Script executed:
# Search for Dialog usage patterns with state management
rg -n -B2 -A5 'useState.*open|Dialog.*open' apps/web/app/routes/ws/deployments --iglob '*.tsx' | head -80Repository: ctrlplanedev/ctrlplane
Length of output: 5873
🏁 Script executed:
# Get the complete DependencyDetail component
cat -n apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx | head -360Repository: ctrlplanedev/ctrlplane
Length of output: 11998
Gate the polling interval on the dialog's open state.
Every mounted DependencyDetail polls every 15 seconds regardless of whether its dialog is open. Since DeploymentVersion renders this per visible version, this creates one background query per version row.
Add dialog state management and conditionally enable the query:
- Track dialog open state with
useState - Pass
open={open} onOpenChange={setOpen}to the<Dialog>component - Gate the query with an
enabledcondition or conditionally setrefetchIntervaltofalsewhen the dialog is closed
This follows the pattern used elsewhere in the codebase for controlled dialogs (e.g., RedeployDialog, VersionActionsPanel).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx`
around lines 323 - 326, DependencyDetail currently polls via
trpc.deploymentVersions.dependencies.useQuery({ versionId }, { refetchInterval:
15_000 }) even when its dialog is closed; add local dialog state (const [open,
setOpen] = useState(false)), pass open={open} onOpenChange={setOpen} to the
Dialog component, and gate the query by either using the enabled: open option or
set refetchInterval: open ? 15_000 : false so the background polling only runs
while the dialog is open.
fixes #1080
Summary by CodeRabbit